aboutsummaryrefslogtreecommitdiff
path: root/src/app/(main)/websites/[websiteId]/(reports)
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)')
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx128
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx63
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx91
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx51
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx46
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx134
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx141
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx36
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx99
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx104
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx36
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css267
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx294
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx67
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx140
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx152
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx18
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx71
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx18
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx12
31 files changed, 2151 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
new file mode 100644
index 0000000..264923a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
@@ -0,0 +1,128 @@
+import { Column, Grid } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface AttributionProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ model: string;
+ type: string;
+ step: string;
+ currency?: string;
+}
+
+export function Attribution({
+ websiteId,
+ startDate,
+ endDate,
+ model,
+ type,
+ step,
+ currency,
+}: AttributionProps) {
+ const { data, error, isLoading } = useResultQuery<any>('attribution', {
+ websiteId,
+ startDate,
+ endDate,
+ model,
+ type,
+ step,
+ });
+
+ const { formatMessage, labels } = useMessages();
+
+ const { pageviews, visitors, visits } = data?.total || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ formatValue: formatLongNumber,
+ },
+ ]
+ : [];
+
+ function AttributionTable({ data = [], title }: { data: any; title: string }) {
+ const attributionData = percentFilter(
+ data.map(({ name, value }) => ({
+ x: name,
+ y: Number(value),
+ })),
+ );
+
+ return (
+ <ListTable
+ title={title}
+ metric={formatMessage(currency ? labels.revenue : labels.visitors)}
+ currency={currency}
+ data={attributionData.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+ }
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Column gap>
+ <MetricsBar>
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+ <MetricCard key={label} value={value} label={label} formatValue={formatValue} />
+ );
+ })}
+ </MetricsBar>
+ <SectionHeader title={formatMessage(labels.sources)} />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Panel>
+ <AttributionTable data={data?.referrer} title={formatMessage(labels.referrer)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.paidAds} title={formatMessage(labels.paidAds)} />
+ </Panel>
+ </Grid>
+ <SectionHeader title="UTM" />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Panel>
+ <AttributionTable data={data?.utm_source} title={formatMessage(labels.sources)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_medium} title={formatMessage(labels.medium)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_cmapaign} title={formatMessage(labels.campaigns)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_content} title={formatMessage(labels.content)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_term} title={formatMessage(labels.terms)} />
+ </Panel>
+ </Grid>
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
new file mode 100644
index 0000000..48611c4
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
@@ -0,0 +1,63 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Attribution } from './Attribution';
+
+export function AttributionPage({ websiteId }: { websiteId: string }) {
+ const [model, setModel] = useState('first-click');
+ const [type, setType] = useState('path');
+ const [step, setStep] = useState('/');
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap="6">
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap>
+ <Column>
+ <Select
+ label={formatMessage(labels.model)}
+ value={model}
+ defaultValue={model}
+ onChange={setModel}
+ >
+ <ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem>
+ <ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem>
+ </Select>
+ </Column>
+ <Column>
+ <Select
+ label={formatMessage(labels.type)}
+ value={type}
+ defaultValue={type}
+ onChange={setType}
+ >
+ <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
+ <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
+ </Select>
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.conversionStep)}
+ value={step}
+ defaultValue={step}
+ onSearch={setStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Attribution
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ model={model}
+ type={type}
+ step={step}
+ />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
new file mode 100644
index 0000000..1368d4b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { AttributionPage } from './AttributionPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <AttributionPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Attribution',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
new file mode 100644
index 0000000..4532d97
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
@@ -0,0 +1,91 @@
+import { Column, DataColumn, DataTable, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks';
+import { formatShortTime } from '@/lib/format';
+
+export interface BreakdownProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ selectedFields: string[];
+}
+
+export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { fields } = useFields();
+ const { data, error, isLoading } = useResultQuery<any>(
+ 'breakdown',
+ {
+ websiteId,
+ startDate,
+ endDate,
+ fields: selectedFields,
+ },
+ { enabled: !!selectedFields.length },
+ );
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Column overflow="auto" minHeight="0" height="100%">
+ <DataTable data={data} style={{ tableLayout: 'fixed' }}>
+ {selectedFields.map(field => {
+ return (
+ <DataColumn
+ key={field}
+ id={field}
+ label={fields.find(f => f.name === field)?.label}
+ width="minmax(120px, 1fr)"
+ >
+ {row => {
+ const value = formatValue(row[field], field);
+ return (
+ <Text truncate title={value}>
+ {value}
+ </Text>
+ );
+ }}
+ </DataColumn>
+ );
+ })}
+ <DataColumn
+ id="visitors"
+ label={formatMessage(labels.visitors)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visitors?.toLocaleString()}
+ </DataColumn>
+ <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px">
+ {row => row?.visits?.toLocaleString()}
+ </DataColumn>
+ <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px">
+ {row => row?.views?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="bounceRate"
+ label={formatMessage(labels.bounceRate)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ </DataColumn>
+ <DataColumn
+ id="visitDuration"
+ label={formatMessage(labels.visitDuration)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ </DataColumn>
+ </DataTable>
+ </Column>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
new file mode 100644
index 0000000..fdead9f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
@@ -0,0 +1,51 @@
+'use client';
+import { Column, Row } from '@umami/react-zen';
+import { useState } from 'react';
+import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { ListCheck } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { Breakdown } from './Breakdown';
+
+export function BreakdownPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [fields, setFields] = useState(['path']);
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Row alignItems="center" justifyContent="flex-start">
+ <FieldsButton value={fields} onChange={setFields} />
+ </Row>
+ <Panel height="900px" overflow="auto" allowFullscreen>
+ <Breakdown
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ selectedFields={fields}
+ />
+ </Panel>
+ </Column>
+ );
+}
+
+const FieldsButton = ({ value, onChange }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<ListCheck />}
+ label={formatMessage(labels.fields)}
+ width="400px"
+ minHeight="300px"
+ variant="outline"
+ >
+ {({ close }) => {
+ return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
new file mode 100644
index 0000000..28e3368
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
@@ -0,0 +1,46 @@
+import { Button, Column, Grid, List, ListItem } from '@umami/react-zen';
+import { useState } from 'react';
+import { useFields, useMessages } from '@/components/hooks';
+
+export function FieldSelectForm({
+ selectedFields = [],
+ onChange,
+ onClose,
+}: {
+ selectedFields?: string[];
+ onChange: (values: string[]) => void;
+ onClose?: () => void;
+}) {
+ const [selected, setSelected] = useState(selectedFields);
+ const { formatMessage, labels } = useMessages();
+ const { fields } = useFields();
+
+ const handleChange = (value: string[]) => {
+ setSelected(value);
+ };
+
+ const handleApply = () => {
+ onChange?.(selected);
+ onClose();
+ };
+
+ return (
+ <Column gap="6">
+ <List value={selected} onChange={handleChange} selectionMode="multiple">
+ {fields.map(({ name, label }) => {
+ return (
+ <ListItem key={name} id={name}>
+ {label}
+ </ListItem>
+ );
+ })}
+ </List>
+ <Grid columns="1fr 1fr" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button onPress={handleApply} variant="primary">
+ {formatMessage(labels.apply)}
+ </Button>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
new file mode 100644
index 0000000..841d863
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { BreakdownPage } from './BreakdownPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <BreakdownPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Insights',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
new file mode 100644
index 0000000..e336a3d
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
@@ -0,0 +1,134 @@
+import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { File, User } from '@/components/icons';
+import { ReportEditButton } from '@/components/input/ReportEditButton';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { Lightning } from '@/components/svg';
+import { formatLongNumber } from '@/lib/format';
+import { FunnelEditForm } from './FunnelEditForm';
+
+type FunnelResult = {
+ type: string;
+ value: string;
+ visitors: number;
+ previous: number;
+ dropped: number;
+ dropoff: number;
+ remaining: number;
+};
+
+export function Funnel({ id, name, type, parameters, websiteId }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery(type, {
+ websiteId,
+ ...parameters,
+ });
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Grid gap>
+ <Grid columns="1fr auto" gap>
+ <Column gap>
+ <Row>
+ <Text size="4" weight="bold">
+ {name}
+ </Text>
+ </Row>
+ </Column>
+ <Column>
+ <ReportEditButton id={id} name={name} type={type}>
+ {({ close }) => {
+ return (
+ <Dialog
+ title={formatMessage(labels.funnel)}
+ variant="modal"
+ style={{ minHeight: 300, minWidth: 400 }}
+ >
+ <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
+ </Dialog>
+ );
+ }}
+ </ReportEditButton>
+ </Column>
+ </Grid>
+ {data?.map(
+ (
+ { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
+ index: number,
+ ) => {
+ const isPage = type === 'path';
+ return (
+ <Grid key={index} columns="auto 1fr" gap="6">
+ <Column alignItems="center" position="relative">
+ <Row
+ borderRadius="full"
+ backgroundColor="3"
+ width="40px"
+ height="40px"
+ justifyContent="center"
+ alignItems="center"
+ style={{ zIndex: 1 }}
+ >
+ <Text weight="bold" size="3">
+ {index + 1}
+ </Text>
+ </Row>
+ {index > 0 && (
+ <Box
+ position="absolute"
+ backgroundColor="3"
+ width="2px"
+ height="120px"
+ top="-100%"
+ />
+ )}
+ </Column>
+ <Column gap>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text color="muted">
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+ </Text>
+ <Text color="muted">{formatMessage(labels.conversionRate)}</Text>
+ </Row>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Row alignItems="center" gap>
+ <Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
+ <Text>{value}</Text>
+ </Row>
+ <Row alignItems="center" gap>
+ {index > 0 && (
+ <ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
+ {formatLongNumber(dropped)}
+ </ChangeLabel>
+ )}
+ <Icon>
+ <User />
+ </Icon>
+ <Text title={visitors.toString()} transform="lowercase">
+ {`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
+ </Text>
+ </Row>
+ </Row>
+ <Row alignItems="center" gap="6">
+ <ProgressBar
+ value={visitors || 0}
+ minValue={0}
+ maxValue={previous || 1}
+ style={{ width: '100%' }}
+ />
+ <Row minWidth="90px" justifyContent="end">
+ <Text weight="bold" size="7">
+ {Math.round(remaining * 100)}%
+ </Text>
+ </Row>
+ </Row>
+ </Column>
+ </Grid>
+ );
+ },
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
new file mode 100644
index 0000000..29b5480
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
@@ -0,0 +1,28 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { FunnelEditForm } from './FunnelEditForm';
+
+export function FunnelAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.funnel)}</Text>
+ </Button>
+ <Modal>
+ <Dialog
+ variant="modal"
+ title={formatMessage(labels.funnel)}
+ style={{ minHeight: 375, minWidth: 600 }}
+ >
+ {({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
new file mode 100644
index 0000000..5d950ea
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
@@ -0,0 +1,141 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormFieldArray,
+ FormSubmitButton,
+ Grid,
+ Icon,
+ Loading,
+ Row,
+ Text,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
+import { Plus, X } from '@/components/icons';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { LookupField } from '@/components/input/LookupField';
+
+const FUNNEL_STEPS_MAX = 8;
+
+export function FunnelEditForm({
+ id,
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ id?: string;
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useReportQuery(id);
+ const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
+
+ const handleSubmit = async ({ name, ...parameters }) => {
+ await mutateAsync(
+ { ...data, id, name, type: 'funnel', websiteId, parameters },
+ {
+ onSuccess: async () => {
+ touch('reports:funnel');
+ touch(`report:${id}`);
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ name: data?.name || '',
+ window: data?.parameters?.window || 60,
+ steps: data?.parameters?.steps || [{ type: 'path', value: '' }],
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+ <FormField
+ name="window"
+ label={formatMessage(labels.window)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField />
+ </FormField>
+ <FormFieldArray
+ name="steps"
+ label={formatMessage(labels.steps)}
+ rules={{
+ validate: value => value.length > 1 || 'At least two steps are required',
+ }}
+ >
+ {({ fields, append, remove }) => {
+ return (
+ <Grid gap>
+ {fields.map(({ id }: { id: string }, index: number) => {
+ return (
+ <Grid key={id} columns="260px 1fr auto" gap>
+ <Column>
+ <FormField
+ name={`steps.${index}.type`}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name={`steps.${index}.value`}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field, context }) => {
+ const type = context.watch(`steps.${index}.type`);
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ <Button onPress={() => remove(index)}>
+ <Icon size="sm">
+ <X />
+ </Icon>
+ </Button>
+ </Grid>
+ );
+ })}
+ <Row>
+ <Button
+ onPress={() => append({ type: 'path', value: '' })}
+ isDisabled={fields.length >= FUNNEL_STEPS_MAX}
+ >
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.add)}</Text>
+ </Button>
+ </Row>
+ </Grid>
+ );
+ }}
+ </FormFieldArray>
+ <FormButtons>
+ <Button onPress={onClose} isDisabled={isPending}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
new file mode 100644
index 0000000..57bce52
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useDateRange, useReportsQuery } from '@/components/hooks';
+import { Funnel } from './Funnel';
+import { FunnelAddButton } from './FunnelAddButton';
+
+export function FunnelsPage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' });
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <SectionHeader>
+ <FunnelAddButton websiteId={websiteId} />
+ </SectionHeader>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Grid gap>
+ {data.data?.map((report: any) => (
+ <Panel key={report.id}>
+ <Funnel {...report} startDate={startDate} endDate={endDate} />
+ </Panel>
+ ))}
+ </Grid>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
new file mode 100644
index 0000000..2fdcf3b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { FunnelsPage } from './FunnelsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <FunnelsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Funnels',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
new file mode 100644
index 0000000..b6c4a11
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
@@ -0,0 +1,99 @@
+import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { File, User } from '@/components/icons';
+import { ReportEditButton } from '@/components/input/ReportEditButton';
+import { Lightning } from '@/components/svg';
+import { formatLongNumber } from '@/lib/format';
+import { GoalEditForm } from './GoalEditForm';
+
+export interface GoalProps {
+ id: string;
+ name: string;
+ type: string;
+ parameters: {
+ name: string;
+ type: string;
+ value: string;
+ };
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export type GoalData = { num: number; total: number };
+
+export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
+ websiteId,
+ startDate,
+ endDate,
+ ...parameters,
+ });
+ const isPage = parameters?.type === 'path';
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <Grid gap>
+ <Grid columns="1fr auto" gap>
+ <Column gap>
+ <Row>
+ <Text size="4" weight="bold">
+ {name}
+ </Text>
+ </Row>
+ </Column>
+ <Column>
+ <ReportEditButton id={id} name={name} type={type}>
+ {({ close }) => {
+ return (
+ <Dialog
+ title={formatMessage(labels.goal)}
+ variant="modal"
+ style={{ minHeight: 300, minWidth: 400 }}
+ >
+ <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
+ </Dialog>
+ );
+ }}
+ </ReportEditButton>
+ </Column>
+ </Grid>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text color="muted">
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+ </Text>
+ <Text color="muted">{formatMessage(labels.conversionRate)}</Text>
+ </Row>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Row alignItems="center" gap>
+ <Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon>
+ <Text>{parameters.value}</Text>
+ </Row>
+ <Row alignItems="center" gap>
+ <Icon>
+ <User />
+ </Icon>
+ <Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
+ data?.num,
+ )} / ${formatLongNumber(data?.total)}`}</Text>
+ </Row>
+ </Row>
+ <Row alignItems="center" gap="6">
+ <ProgressBar
+ value={data?.num || 0}
+ minValue={0}
+ maxValue={data?.total || 1}
+ style={{ width: '100%' }}
+ />
+ <Text weight="bold" size="7">
+ {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
+ </Text>
+ </Row>
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
new file mode 100644
index 0000000..c85b79c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
@@ -0,0 +1,28 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { GoalEditForm } from './GoalEditForm';
+
+export function GoalAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.goal)}</Text>
+ </Button>
+ <Modal>
+ <Dialog
+ aria-label="add goal"
+ title={formatMessage(labels.goal)}
+ style={{ minWidth: 400, minHeight: 300 }}
+ >
+ {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
new file mode 100644
index 0000000..7f68047
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
@@ -0,0 +1,104 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Grid,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { LookupField } from '@/components/input/LookupField';
+
+export function GoalEditForm({
+ id,
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ id?: string;
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useReportQuery(id);
+ const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
+
+ const handleSubmit = async (formData: Record<string, any>) => {
+ await mutateAsync(
+ { ...formData, type: 'goal', websiteId },
+ {
+ onSuccess: async () => {
+ if (id) touch(`report:${id}`);
+ touch('reports:goal');
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ name: '',
+ parameters: { type: 'path', value: '' },
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}>
+ {({ watch }) => {
+ const type = watch('parameters.type');
+
+ return (
+ <>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+ <Column>
+ <Label>{formatMessage(labels.action)}</Label>
+ <Grid columns="260px 1fr" gap>
+ <Column>
+ <FormField
+ name="parameters.type"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name="parameters.value"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field }) => {
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ </Grid>
+ </Column>
+
+ <FormButtons>
+ <Button onPress={onClose} isDisabled={isPending}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
new file mode 100644
index 0000000..ff7b49f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useDateRange, useReportsQuery } from '@/components/hooks';
+import { Goal } from './Goal';
+import { GoalAddButton } from './GoalAddButton';
+
+export function GoalsPage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <SectionHeader>
+ <GoalAddButton websiteId={websiteId} />
+ </SectionHeader>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ {data.data.map((report: any) => (
+ <Panel key={report.id}>
+ <Goal {...report} startDate={startDate} endDate={endDate} />
+ </Panel>
+ ))}
+ </Grid>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
new file mode 100644
index 0000000..b1ab691
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { GoalsPage } from './GoalsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <GoalsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Goals',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
new file mode 100644
index 0000000..63643f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
@@ -0,0 +1,267 @@
+.container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ --journey-line-color: var(--base-color-6);
+ --journey-active-color: var(--primary-color);
+ --journey-faded-color: var(--base-color-3);
+}
+
+.view {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow: auto;
+ gap: 100px;
+ padding-right: 20px;
+}
+
+.header {
+ margin-bottom: 20px;
+}
+
+.stats {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ width: 100%;
+}
+
+.visitors {
+ font-weight: 600;
+ font-size: 16px;
+ text-transform: lowercase;
+}
+
+.dropoff {
+ font-weight: 600;
+ color: var(--font-color-muted);
+ background: var(--base-color-2);
+ padding: 4px 8px;
+ border-radius: 5px;
+}
+
+.num {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ width: 50px;
+ height: 50px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+ z-index: 1;
+ margin: 0 auto 20px;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.nodes {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.wrapper {
+ padding-bottom: 10px;
+}
+
+.node {
+ position: relative;
+ cursor: pointer;
+ padding: 10px 20px;
+ background: var(--base-color-3);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 300px;
+ max-width: 300px;
+ height: 60px;
+ max-height: 60px;
+}
+
+.node:hover:not(.selected) {
+ background: var(--base-color-4);
+}
+
+.node.selected {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.active {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.node.selected .count {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.selected.active .count {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.name {
+ max-width: 200px;
+}
+
+.line {
+ position: absolute;
+ bottom: 0;
+ left: -100px;
+ width: 100px;
+ pointer-events: none;
+}
+
+.line.up {
+ bottom: 0;
+}
+
+.line.down {
+ top: 0;
+}
+
+.segment {
+ position: absolute;
+}
+
+.start {
+ left: 0;
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.mid {
+ top: 60px;
+ width: 50px;
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.end {
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.up .start {
+ top: 30px;
+ border-top-right-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.up .end {
+ width: 52px;
+ bottom: 27px;
+ right: 0;
+ border-bottom-left-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.down .start {
+ bottom: 27px;
+ border-bottom-right-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.down .end {
+ width: 52px;
+ top: 30px;
+ right: 0;
+ border-top-left-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.flat .start {
+ left: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.flat .end {
+ right: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.start:before,
+.end:before {
+ content: "";
+ position: absolute;
+ border-radius: 100%;
+ border: 3px solid var(--journey-line-color);
+ background: var(--base-color-1);
+ width: 14px;
+ height: 14px;
+}
+
+.line:not(.active) .start:before,
+.line:not(.active) .end:before {
+ display: none;
+}
+
+.up .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.up .end:before {
+ right: -8px;
+ bottom: -8px;
+}
+
+.down .start:before {
+ left: -8px;
+ bottom: -8px;
+}
+
+.down .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.flat .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.flat .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.line.active .segment,
+.line.active .segment:before {
+ border-color: var(--journey-active-color);
+ z-index: 1;
+}
+
+.column.active .line:not(.active) .segment {
+ border-color: var(--journey-faded-color);
+}
+
+.column.active .line:not(.active) .segment:before {
+ display: none;
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
new file mode 100644
index 0000000..3327a42
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
@@ -0,0 +1,294 @@
+import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import classNames from 'classnames';
+import { useMemo, useState } from 'react';
+import { firstBy } from 'thenby';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
+import { File } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+import { objectToArray } from '@/lib/data';
+import { formatLongNumber } from '@/lib/format';
+import styles from './Journey.module.css';
+
+const NODE_HEIGHT = 60;
+const NODE_GAP = 10;
+const LINE_WIDTH = 3;
+
+export interface JourneyProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ steps: number;
+ startStep?: string;
+ endStep?: string;
+}
+
+export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [activeNode, setActiveNode] = useState(null);
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('journey', {
+ websiteId,
+ steps,
+ startStep,
+ endStep,
+ });
+
+ useEscapeKey(() => setSelectedNode(null));
+
+ const columns = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ const selectedPaths = selectedNode?.paths ?? [];
+ const activePaths = activeNode?.paths ?? [];
+ const columns = [];
+
+ for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
+ const nodes = {};
+
+ data.forEach(({ items, count }: any, nodeIndex: any) => {
+ const name = items[columnIndex];
+
+ if (name) {
+ const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
+ const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
+
+ if (!nodes[name]) {
+ const paths = data.filter(({ items }) => items[columnIndex] === name);
+
+ nodes[name] = {
+ name,
+ count,
+ totalCount: count,
+ nodeIndex,
+ columnIndex,
+ selected,
+ active,
+ paths,
+ pathMap: paths.map(({ items, count }) => ({
+ [`${columnIndex}:${items.join(':')}`]: count,
+ })),
+ };
+ } else {
+ nodes[name].totalCount += count;
+ }
+ }
+ });
+
+ columns.push({
+ nodes: objectToArray(nodes).sort(firstBy('total', -1)),
+ });
+ }
+
+ columns.forEach((column, columnIndex) => {
+ const nodes = column.nodes.map(
+ (
+ currentNode: { totalCount: number; name: string; selected: boolean },
+ currentNodeIndex: any,
+ ) => {
+ const previousNodes = columns[columnIndex - 1]?.nodes;
+ let selectedCount = previousNodes ? 0 : currentNode.totalCount;
+ let activeCount = selectedCount;
+
+ const lines =
+ previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
+ const fromCount = selectedNode?.paths.reduce((sum, path) => {
+ if (
+ previousNode.name === path.items[columnIndex - 1] &&
+ currentNode.name === path.items[columnIndex]
+ ) {
+ sum += path.count;
+ }
+ return sum;
+ }, 0);
+
+ if (currentNode.selected && previousNode.selected && fromCount) {
+ arr.push([previousNodeIndex, currentNodeIndex]);
+ selectedCount += fromCount;
+
+ if (previousNode.active) {
+ activeCount += fromCount;
+ }
+ }
+
+ return arr;
+ }, []) || [];
+
+ return { ...currentNode, selectedCount, activeCount, lines };
+ },
+ );
+
+ const visitorCount = nodes.reduce(
+ (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
+ if (!selectedNode) {
+ sum += totalCount;
+ } else if (!activeNode && selectedNode && selected) {
+ sum += selectedCount;
+ } else if (activeNode && active) {
+ sum += activeCount;
+ }
+ return sum;
+ },
+ 0,
+ );
+
+ const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
+ const dropOff =
+ previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
+
+ Object.assign(column, { nodes, visitorCount, dropOff });
+ });
+
+ return columns;
+ }, [data, selectedNode, activeNode]);
+
+ const handleClick = (name: string, columnIndex: number, paths: any[]) => {
+ if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
+ setSelectedNode({ name, columnIndex, paths });
+ } else {
+ setSelectedNode(null);
+ }
+ setActiveNode(null);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
+ <div className={styles.container}>
+ <div className={styles.view}>
+ {columns.map(({ visitorCount, nodes }, columnIndex) => {
+ return (
+ <div
+ key={columnIndex}
+ className={classNames(styles.column, {
+ [styles.selected]: selectedNode,
+ [styles.active]: activeNode,
+ })}
+ >
+ <div className={styles.header}>
+ <div className={styles.num}>{columnIndex + 1}</div>
+ <div className={styles.stats}>
+ <div className={styles.visitors} title={visitorCount}>
+ {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
+ </div>
+ </div>
+ </div>
+ <div className={styles.nodes}>
+ {nodes.map(
+ ({
+ name,
+ totalCount,
+ selected,
+ active,
+ paths,
+ activeCount,
+ selectedCount,
+ lines,
+ }) => {
+ const nodeCount = selected
+ ? active
+ ? activeCount
+ : selectedCount
+ : totalCount;
+
+ const remaining =
+ columnIndex > 0
+ ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
+ : 0;
+
+ const dropped = 100 - remaining;
+
+ return (
+ <div
+ key={name}
+ className={styles.wrapper}
+ onMouseEnter={() =>
+ selected && setActiveNode({ name, columnIndex, paths })
+ }
+ onMouseLeave={() => selected && setActiveNode(null)}
+ >
+ <div
+ className={classNames(styles.node, {
+ [styles.selected]: selected,
+ [styles.active]: active,
+ })}
+ onClick={() => handleClick(name, columnIndex, paths)}
+ >
+ <Row alignItems="center" className={styles.name} title={name} gap>
+ <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
+ <Text truncate>{name}</Text>
+ </Row>
+ <div className={styles.count} title={nodeCount}>
+ <TooltipTrigger
+ delay={0}
+ isDisabled={columnIndex === 0 || (selectedNode && !selected)}
+ >
+ <Focusable>
+ <div>{formatLongNumber(nodeCount)}</div>
+ </Focusable>
+ <Tooltip placement="top" offset={20} showArrow>
+ <Text transform="lowercase" color="ruby">
+ {`${dropped}% ${formatMessage(labels.dropoff)}`}
+ </Text>
+ <Column>
+ <Text transform="lowercase">
+ {`${remaining}% ${formatMessage(labels.conversion)}`}
+ </Text>
+ </Column>
+ </Tooltip>
+ </TooltipTrigger>
+ </div>
+ {columnIndex < columns.length &&
+ lines.map(([fromIndex, nodeIndex], i) => {
+ const height =
+ (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
+ NODE_GAP;
+ const midHeight =
+ (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
+ NODE_GAP +
+ LINE_WIDTH;
+ const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
+
+ return (
+ <div
+ key={`${fromIndex}${nodeIndex}${i}`}
+ className={classNames(styles.line, {
+ [styles.active]:
+ active &&
+ activeNode?.paths.find(
+ (path: { items: any[] }) =>
+ path.items[columnIndex] === name &&
+ path.items[columnIndex - 1] === nodeName,
+ ),
+ [styles.up]: fromIndex < nodeIndex,
+ [styles.down]: fromIndex > nodeIndex,
+ [styles.flat]: fromIndex === nodeIndex,
+ })}
+ style={{ height }}
+ >
+ <div className={classNames(styles.segment, styles.start)} />
+ <div
+ className={classNames(styles.segment, styles.mid)}
+ style={{
+ height: midHeight,
+ }}
+ />
+ <div className={classNames(styles.segment, styles.end)} />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ },
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
new file mode 100644
index 0000000..14b8341
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
@@ -0,0 +1,67 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Journey } from './Journey';
+
+const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
+const DEFAULT_STEP = 3;
+
+export function JourneysPage({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [steps, setSteps] = useState(DEFAULT_STEP);
+ const [startStep, setStartStep] = useState('');
+ const [endStep, setEndStep] = useState('');
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns="repeat(3, 1fr)" gap>
+ <Select
+ items={JOURNEY_STEPS}
+ label={formatMessage(labels.steps)}
+ value={steps}
+ defaultValue={steps}
+ onChange={setSteps}
+ >
+ {JOURNEY_STEPS.map(step => (
+ <ListItem key={step} id={step}>
+ {step}
+ </ListItem>
+ ))}
+ </Select>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.startStep)}
+ value={startStep}
+ onSearch={setStartStep}
+ delay={1000}
+ />
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.endStep)}
+ value={endStep}
+ onSearch={setEndStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Panel height="900px" allowFullscreen>
+ <Journey
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ steps={steps}
+ startStep={startStep}
+ endStep={endStep}
+ />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
new file mode 100644
index 0000000..f6062a6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { JourneysPage } from './JourneysPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <JourneysPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Journeys',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
new file mode 100644
index 0000000..fdd8a14
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
@@ -0,0 +1,140 @@
+import { Column, Grid, Icon, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
+import { Users } from '@/components/icons';
+import { formatDate } from '@/lib/date';
+import { formatLongNumber } from '@/lib/format';
+
+const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
+
+export interface RetentionProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ days?: number[];
+}
+
+export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { data, error, isLoading } = useResultQuery('retention', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ const rows =
+ data?.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
+ const { date, visitors, day } = row;
+ if (day === 0) {
+ return arr.concat({
+ date,
+ visitors,
+ records: days
+ .reduce((arr, day) => {
+ arr[day] = data.find(
+ (x: { date: any; day: number }) => x.date === date && x.day === day,
+ );
+ return arr;
+ }, [])
+ .filter(n => n),
+ });
+ }
+ return arr;
+ }, []) || [];
+
+ const totalDays = rows.length;
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Panel allowFullscreen height="900px">
+ <Column
+ paddingY="6"
+ paddingX={{ xs: '3', md: '6' }}
+ position="absolute"
+ top="40px"
+ left="0"
+ right="0"
+ bottom="0"
+ >
+ <Column gap="1" overflow="auto">
+ <Grid
+ columns="120px repeat(10, 100px)"
+ alignItems="center"
+ gap="1"
+ height="50px"
+ width="max-content"
+ minWidth="100%"
+ autoFlow="column"
+ >
+ <Column>
+ <Text weight="bold" align="center">
+ {formatMessage(labels.cohort)}
+ </Text>
+ </Column>
+ {days.map(n => (
+ <Column key={n}>
+ <Text weight="bold" align="center" wrap="nowrap">
+ {formatMessage(labels.day)} {n}
+ </Text>
+ </Column>
+ ))}
+ </Grid>
+ {rows.map(({ date, visitors, records }: any, rowIndex: number) => {
+ return (
+ <Grid
+ key={rowIndex}
+ columns="120px repeat(10, 100px)"
+ gap="1"
+ autoFlow="column"
+ width="max-content"
+ minWidth="100%"
+ >
+ <Column justifyContent="center" gap="1">
+ <Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
+ <Row alignItems="center" gap>
+ <Icon>
+ <Users />
+ </Icon>
+ <Text>{formatLongNumber(visitors)}</Text>
+ </Row>
+ </Column>
+ {days.map(day => {
+ if (totalDays - rowIndex < day) {
+ return null;
+ }
+ const percentage = records.filter(a => a.day === day)[0]?.percentage;
+ return (
+ <Cell key={day}>
+ {percentage ? `${Number(percentage).toFixed(2)}%` : ''}
+ </Cell>
+ );
+ })}
+ </Grid>
+ );
+ })}
+ </Column>
+ </Column>
+ </Panel>
+ )}
+ </LoadingPanel>
+ );
+}
+
+const Cell = ({ children }: { children: ReactNode }) => {
+ return (
+ <Column
+ justifyContent="center"
+ alignItems="center"
+ width="100px"
+ height="100px"
+ backgroundColor="2"
+ borderRadius
+ >
+ {children}
+ </Column>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
new file mode 100644
index 0000000..0ec6e95
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { endOfMonth, startOfMonth } from 'date-fns';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { Retention } from './Retention';
+
+export function RetentionPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate },
+ } = useDateRange();
+
+ const monthStartDate = startOfMonth(startDate);
+ const monthEndDate = endOfMonth(startDate);
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter />
+ <Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
new file mode 100644
index 0000000..2fbbc0a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RetentionPage } from './RetentionPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RetentionPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Retention',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
new file mode 100644
index 0000000..0e782a1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
@@ -0,0 +1,152 @@
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import classNames from 'classnames';
+import { colord } from 'colord';
+import { useCallback, useMemo, useState } from 'react';
+import { BarChart } from '@/components/charts/BarChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
+import { CurrencySelect } from '@/components/input/CurrencySelect';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { generateTimeSeries } from '@/lib/date';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+export interface RevenueProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ unit: string;
+}
+
+export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
+ const [currency, setCurrency] = useState('USD');
+ const { formatMessage, labels } = useMessages();
+ const { locale, dateLocale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { data, error, isLoading } = useResultQuery<any>('revenue', {
+ websiteId,
+ startDate,
+ endDate,
+ currency,
+ });
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+ <Row className={classNames(locale)} gap>
+ <TypeIcon type="country" value={code} />
+ <Text>{countryNames[code] || formatMessage(labels.unknown)}</Text>
+ </Row>
+ ),
+ [countryNames, locale],
+ );
+
+ const chartData: any = useMemo(() => {
+ if (!data) return [];
+
+ const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const metrics = useMemo(() => {
+ if (!data) return [];
+
+ const { sum, count, unique_count } = data.total;
+
+ return [
+ {
+ value: sum,
+ label: formatMessage(labels.total),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count ? sum / count : 0,
+ label: formatMessage(labels.average),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count,
+ label: formatMessage(labels.transactions),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: unique_count,
+ label: formatMessage(labels.uniqueCustomers),
+ formatValue: formatLongNumber,
+ },
+ ] as any;
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <Column gap>
+ <Grid columns="280px" gap>
+ <CurrencySelect value={currency} onChange={setCurrency} />
+ </Grid>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Column gap>
+ <MetricsBar>
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+ <MetricCard key={label} value={value} label={label} formatValue={formatValue} />
+ );
+ })}
+ </MetricsBar>
+ <Panel>
+ <BarChart
+ chartData={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ stacked={true}
+ currency={currency}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ </Panel>
+ <Panel>
+ <ListTable
+ title={formatMessage(labels.country)}
+ metric={formatMessage(labels.revenue)}
+ data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
+ label: name,
+ count: Number(value),
+ percent: (value / data?.total.sum) * 100,
+ }))}
+ currency={currency}
+ renderLabel={renderCountryName}
+ />
+ </Panel>
+ </Column>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
new file mode 100644
index 0000000..3e429c1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { Revenue } from './Revenue';
+
+export function RevenuePage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
new file mode 100644
index 0000000..e30d54c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
@@ -0,0 +1,21 @@
+import { DataColumn, DataTable } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { formatLongCurrency } from '@/lib/format';
+
+export function RevenueTable({ data = [] }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="currency" label={formatMessage(labels.currency)} align="end" />
+ <DataColumn id="total" label={formatMessage(labels.total)} align="end">
+ {(row: any) => formatLongCurrency(row.sum, row.currency)}
+ </DataColumn>
+ <DataColumn id="average" label={formatMessage(labels.average)} align="end">
+ {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
+ </DataColumn>
+ <DataColumn id="count" label={formatMessage(labels.transactions)} align="end" />
+ <DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" />
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
new file mode 100644
index 0000000..fba10f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RevenuePage } from './RevenuePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RevenuePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Revenue',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
new file mode 100644
index 0000000..1399174
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
@@ -0,0 +1,71 @@
+import { Column, Grid, Heading, Text } from '@umami/react-zen';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
+
+export interface UTMProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export function UTM({ websiteId, startDate, endDate }: UTMProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('utm', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px">
+ {data && (
+ <Column gap>
+ {UTM_PARAMS.map(param => {
+ const items = data?.[param];
+
+ const chartData = {
+ labels: items.map(({ utm }) => utm),
+ datasets: [
+ {
+ data: items.map(({ views }) => views),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ const total = items.reduce((sum, { views }) => {
+ return +sum + +views;
+ }, 0);
+
+ return (
+ <Panel key={param}>
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6">
+ <Column>
+ <Heading>
+ <Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
+ </Heading>
+ <ListTable
+ metric={formatMessage(labels.views)}
+ data={items.map(({ utm, views }) => ({
+ label: utm,
+ count: views,
+ percent: (views / total) * 100,
+ }))}
+ />
+ </Column>
+ <Column>
+ <PieChart type="doughnut" chartData={chartData} />
+ </Column>
+ </Grid>
+ </Panel>
+ );
+ })}
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
new file mode 100644
index 0000000..0d2a732
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { UTM } from './UTM';
+
+export function UTMPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
new file mode 100644
index 0000000..8b8fd6a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { UTMPage } from './UTMPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <UTMPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'UTM Parameters',
+};